<?php
/**
 * Copyright (c) 2009, Jacek Karczmarczyk
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * - Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 * - Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * - Neither the name of the author nor the names of its contributors may be
 *   used to endorse or promote products derived from this software without
 *   specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 *
 *
 * PHP Version 5
 *
 * @package Larchana_Controller
 * @version 0.1
 * @author Jacek Karczmarczyk <jacek@karczmarczyk.pl>
 */


/**
 * Examples:
 * public function indexAction()
 * {
 *     $filterForm = $this->_createFilterForm();
 *     $this->_helper->crud->readAction($filterForm->getValues());
 * }
 *
 * public function editnameAction()
 * {
 *     $this->_helper->crud->updateAction(array('firstname', 'surname', 'email'));
 * }
 *
 * public ajaxdeleteAction()
 * {
 *     if (!$this->getRequest()->isXmlHttpRequest())
 *         $this->_helper->acl->accessDenied();
 * 
 *     $this->_helper->deleteAction();
 * }
 *
 * In the update/create actions the view attribute named "form" is set
 * In the read action the view attribute named "paginator" is set
 *
 */
class Larchana_Controller_Action_Helper_Crud extends Zend_Controller_Action_Helper_Abstract
{
	/**
	 * Table for updating, creating and deleting rows
	 * @var Zend_Db_Table
	 */
	protected $_table;

	/**
	 * Table for reading rows
	 * @var Zend_Db_Table
	 */
	protected $_viewTable;

	/**
	 * Row being edited
	 * @var Zend_Db_Table_Row
	 */
	protected $_row;

	/**
	 * Select expression used to fetch rows for update and delete actions
	 * @var Zend_Db_Table_Select
	 */
	protected $_select;

	/**
	 * Select expression used to fetch rows for read
	 * @var Zend_Db_Table_Select
	 */
	protected $_viewSelect;

	/**
	 * Paginator's items count per page
	 * @var int 
	 */
	protected $_itemCountPerPage = 30;

	/**
	 * User specific order expressions, i.e. array('name'=>'lastname %dir%, firstname %dir%')
	 * @var array 
	 */
	protected $_orderExprs = array();

	/**
	 * Constructor, setups the database tables
	 *
	 * @param Zend_Controller_Action $controller Action controller
	 * @param Zend_Db_Table          $table Table
	 */
	public function __construct(Zend_Controller_Action $controller, Zend_Db_Table $table = null, Zend_Db_Table $viewTable = null)
	{
		$this->setActionController($controller);

		if (is_null($table))
		{
			$class = 'Db_'.ucwords(strtolower(str_replace('Controller', '', get_class($controller))));
			$table = new $class;
		}
		$this->setTable($table, $viewTable);
	}

	/**
	 * Creates the paginator object
	 *
	 * @param array $filterValues
	 * @param array $options Options ('orderName', 'paginatorName')
	 */
	public function readAction(array $filterValues = array(), array $options = array())
	{
		$orderName = isset($options['orderName']) ? $options['orderName'] : 'order';
		$adapter = new Zend_Paginator_Adapter_DbTableSelect(
			$this->applyFilter($filterValues)->applyOrder($orderName)->getViewSelect()
		);
		$paginator = new Zend_Paginator($adapter);
		$paginator->setItemCountPerPage($this->_itemCountPerPage);
		$paginator->setCurrentPageNumber($this->getRequest()->getUserParam('page'));

		$paginatorName = isset($options['paginatorName']) ? $options['paginatorName'] : 'paginator';
		$this->getActionController()->view->assign($paginatorName, $paginator);
	}

	/**
	 * Standard create action (edit and add a new table row)
	 *
	 * @param Zend_Form|array|null $form Form or array of form fields names or null (form will be created automaticly from all columns)
	 * @param array                $options Options, see _editAndSave method
	 * @param Zend_Db_Table_Row    $row
	 * @return Zend_Form|boolean See _editAndSave method
	 */
	public function createAction($form = null, array $options = array(), Zend_Db_Table_Row $row = null)
	{
		$this->setRow($row ? $row : $this->getTable()->createRow());
		$options['setDefaults'] = false;
		if (isset($options['formMethod'])) $options['formMethod'] = 'put';
		return $this->_editAndSave($form, $options);
	}

	/**
	 * Standard update action (edit and save existing table row)
	 *
	 * @param Zend_Form|array|null $form Form or array of form fields names or null (form will be created automaticly from all columns)
	 * @param array                $options Options, see _editAndSave method
	 * @return <type>
	 */
	public function updateAction($form = null, array $options = array())
	{
		$options['setDefaults'] = true;
		if (isset($options['formMethod'])) $options['formMethod'] = 'post';
		return $this->_editAndSave($form, $options);
	}

	/**
	 * Standard delete action
	 *
	 * @param boolean $confirmed True if deletion is confirmed, else the method will check if the request method is DELETE
	 * @return boolean True if row is deleted
	 */
	public function deleteAction($confirmed = false)
	{
		if (!$this->getRequest()->isDelete() && !$confirmed)
		{
			$this->getActionController()->view->row = $this->getRow();
			return false;
		}

		$this->getRow()->delete();
		if ($this->getRequest()->isXmlHttpRequest())
		{
			echo Zend_Json::encode(array('type'=>'success', 'message'=>''));
			exit;
		}

		return true;
	}

	/**
	 * Sets the table object
	 *
	 * @param Zend_Db_Table $table
	 * @return Larchana_Controller_Action_Helper_Crud Provides fluent interface
	 */
	public function setTable(Zend_Db_Table $table, Zend_Db_Table $viewTable = null)
	{
		$this->_table = $table;
		$this->_viewTable = is_null($viewTable) ? $table : $viewTable;
		$this->_row = null;
		$this->resetSelect();
		return $this;
	}

	/**
	 * Returns the table object
	 *
	 * @return Zend_Db_Table
	 */
	public function getTable()
	{
		return $this->_table;
	}

	/**
	 * Returns the table object for read action
	 *
	 * @return Zend_Db_Table
	 */
	public function getViewTable()
	{
		return $this->_viewTable;
	}

	/**
	 * Sets the row object
	 *
	 * @param Zend_Db_Table_Row $row Db row
	 */
	public function setRow(Zend_Db_Table_Row $row)
	{
		if (get_class($row) != $this->getTable()->getRowClass())
			throw new Exception("Row must be instance of ".$this->getTable()->getRowClass());

		$this->_row = $row;
	}

	/**
	 * Returns the row with id specified in the 'id' user parameter, throws and exception if the row is not found
	 *
	 * @param boolean $emptyOnError If true and row not found returns a new empty row
	 * @return Zend_Db_Table_Row Found row
	 * @throws Zend_Controller_Action_Exception
	 */
	public function getRow($emptyOnError = false)
	{
		if (is_null($this->_row))
		{
			$select = $this->getSelect()->where('id=?', (int)$this->getRequest()->getUserParam('id'));
			if (is_null($this->_row = $this->getTable()->fetchRow($select)))
			{
				if ($emptyOnError) return $this->getTable()->createRow();
				throw new Zend_Controller_Action_Exception('', 404);
			}
		}
		return $this->_row;		
	}

	/**
	 * Clears the select statement (where, order etc)
	 *
	 * @return Larchana_Controller_Action_Helper_Crud Provides fluent interface
	 */
	public function resetSelect()
	{
		$this->_select = $this->_viewSelect = $this->getTable()->select();
		if ($this->getTable() !== $this->getViewTable())
		{
			$this->_viewSelect = $this->getViewTable()->select();
		}
		return $this;
	}

	/**
	 * Returns select statement for update/delete actions
	 *
	 * @return Zend_Db_Table_Select Db select statement
	 */
	public function getSelect()
	{
		return $this->_select;
	}

	/**
	 * Returns select statement for read (index) actions
	 *
	 * @return Zend_Db_Table_Select Db select statement
	 */
	public function getViewSelect()
	{
		return $this->_viewSelect;
	}

	/**
	 * Applies user filter to the select statement
	 *
	 * @param array $filterValues
	 * @return Larchana_Controller_Action_Crud Provides fluent interface
	 */
	public function applyFilter(array $filterValues = array())
	{
		$columns = $this->getTable()->info('cols');
		foreach($filterValues as $name=>$data)
		{
			if (is_array($data))
			{
				$this->getSelect()->where($data['expr'], $data['value']);
				$this->getViewSelect()->where($data['expr'], $data['value']);
			}
			else if ((string)$data !== '')
			{
				$this->getSelect()->where($name.'=?', $data);
				$this->getViewSelect()->where($name.'=?', $data);
			}
		}
		return $this;
	}

	/**
	 * Applies order to the select statement
	 *
	 * @param string $name GET variable name for order
	 * @return Larchana_Controller_Action_Crud
	 */
	public function applyOrder($name = 'order')
	{
		if (!isset($_GET[$name]) || !preg_match('/-(a|de)sc$/', $_GET[$name])) return $this;

		$orderName = preg_replace('/-(a|de)sc$/', '', $_GET[$name]);
		$orderDir = strtoupper(substr($_GET[$name], strlen($orderName)+1));
		$orderExpr = isset($this->_orderExprs[$orderName]) ? $this->_orderExprs[$orderName] : $orderName;
		if (false === strstr($orderExpr, '%dir%')) $orderExpr .= ' %dir%';

		$this->getViewSelect()->order(str_replace('%dir%', $orderDir, $orderExpr));
		return $this;
	}

	/**
	 * Sets what order should be applied for a specified column
	 *
	 * @param string $name Column name
	 * @param string $expr SQL order expression
	 */
	public function registerOrderExpr($name, $expr)
	{
		$this->_orderExprs[$name] = $expr;
	}

	/**
	 * Creates form with default elements
	 *
	 * @param array $elements List of table column names which should be included in the form
	 * @return Zend_Form
	 */
	public function buildForm(array $elements = null)
	{
		$form = new Zend_Form();
		$form->addPrefixPath('Larchana_Form', 'Larchana/Form');

		$columns = $this->getTable()->info('metadata');
		if (is_null($elements)) $elements = array_keys($columns);
		foreach($elements as $key=>$value)
		{
			if (is_string($key))
			{
				if (is_string($value))
					$this->addDefaultElement($form, $key, $value);
				else if (is_array($value))
					$this->addDefaultElement($form, $key, isset($value['type']) ? $value['type'] : null, $value);
				else
					$this->addDefaultElement($form, $key);
			}
			else
			{
				$this->addDefaultElement($form, $value);
			}
		}

		return $form;
	}

	/**
	 * Creates the default form element for the specified column
	 *
	 * @param Zend_Form   $form Master form
	 * @param string      $columnName Column name
	 * @param string|null $forceType Forced type (if different than based on column definition)
	 * @return Zend_Form_Element Added element or null
	 */
	public function addDefaultElement(Zend_Form $form, $name, $userType = null, array $userOptions = null)
	{
		// No field for primary key
		//
		if (in_array($name, $this->getTable()->info('primary'))) return null;

		$row = $this->getRow(true);

		$options = array(
			'label'=>$this->getRequest()->getControllerName().'.data.'.strtolower($name),
			'filters'=>$row->getFilter($name),
			'validators'=>$row->getValidator($name)
		);

		// Set default element type
		//
		if (is_string($userType))
		{
			$type = $userType;
		}
		else {
			$columns = $this->getTable()->info('metadata');
			if (!array_key_exists($name, $columns))
				throw new Zend_Controller_Action_Exception("Column \"$name\" does not exists");

			switch($columns[$name]['DATA_TYPE'])
			{
				case 'date': $type = 'date'; break;
				case 'datetime': $type = 'datetime'; break;
				case 'timestamp': $type = 'datetime'; break;
				case 'text': $type = 'textarea'; break;
				default: $type = 'text';
			}

			if (is_array($dictionary = $row->getDictionary($name, @$userOptions['nullValue'])))
			{
				$type = 'select';
				if (!isset($userOptions['options']) && !isset($userOptions['options']['multiOptions']))
				{
					$userOptions['options']['multiOptions'] = $dictionary;
				}
			}
		}

		if (is_array($userOptions) && isset($userOptions['options']) && is_array($userOptions['options']))
			$options = array_merge_recursive($options, $userOptions['options']);

		$element = $form->createElement($type, $name, $options);
		if ($element->getValidator('NotEmpty'))
		{
			$element->setAutoInsertNotEmptyValidator(false);
			$element->setAllowEmpty(false);
			$element->setRequired(true);
		}

		$form->addElement($element);
		return $element;
	}
	
	/**
	 * Shows the form or tries to save the data from the form
	 *
	 * @param Zend_Form|array|null $form Form or array of form fields names or null (form will be created automaticly from all columns)
	 * @param array                $options Options ('setDefaults', 'redirect')
	 * @return Zend_Form|boolean False if not PUT/POST request, true if form validated and saved, Zend_Form if form is not valid
	 */
	protected function _editAndSave($form = null, array $options = array())
	{
		$this->getActionController()->view->form = $form = $this->_prepareForm($form);

		if (isset($options['setDefaults']) && $options['setDefaults'])
			$form->setDefaults($this->getRow()->toArray());

		// Not saving
		//
		if (!$this->getRequest()->isPost() && !$this->getRequest()->isPut())
			return false;

		// Errors in form
		//
		if (!$form->isValid($_POST))
			return $form;

		// Gets unfiltered values, they will be filtered on _dbRow->__set()
		//
		$this->getRow()->setFromArray($form->getUnfilteredValues());
		$this->getRow()->save();
		$this->_redirectOrReturn(isset($options['redirect']) ? $options['redirect'] : true);

		// Form validated, row saved, not redirected
		//
		return true;
	}

	/**
	 * Creates a form (if not already created), sets the method and adds that 'save' button
	 * 
	 * @param Zend_Form|array|null $form See _editAndSave method
	 * @return Zend_Form Created form
	 */
	protected function _prepareForm($form = null)
	{
		if (!($form instanceof Zend_Form))
			$form = $this->buildForm($form);
		$form->setMethod('post')
		     ->addElement('submit', 'save', array('label'=>'default.crud.save'))
		     ->getElement('save')
		     ->setIgnore(true);
		return $form;
	}

	/**
	 * Redirects to an url, route or default url based on the current controller name and default action for this controller
	 *
	 * @param string|array|boolean $redirect Url, array for route ('action', 'controller', etc), true for default route or false for not redirecting
	 */
	protected function _redirectOrReturn($redirect = false)
	{
		if (!$redirect)
			return;

		$redirector = $this->getActionController()->getHelper('redirector');
		if (is_string($redirect))
			$redirector->gotoUrl($redirect);
		else if (is_array($redirect))
			$redirector->gotoRoute($redirect);
		else
			$redirector->gotoUrl('/'.$this->getRequest()->getControllerName());
	}

	/*
	protected function _setFormMessages(Zend_Form $form)
	{
		$messages = array();
		foreach($this->getRow()->getErrors() as $columnName=>$columnErrors)
		{
			if ($element = $form->getElement($columnName))
				$element->setErrors($columnErrors);
			else
				$messages += $columnErrors;
		}

		if (sizeof($messages))
			$form->setErrors($messages);
	}
	*/

}
